-
Notifications
You must be signed in to change notification settings - Fork 400
api: Embed platform and app info in user-agent #724
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
rajveermalviya
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@gnprice, some questions about the user-agent format, PR as a whole is still WIP.
0131fb8 to
d94d4ed
Compare
test/api/fake_api_test.dart
Outdated
| import 'fake_api.dart'; | ||
|
|
||
| void main() { | ||
| TestZulipBinding.ensureInitialized(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@gnprice Thoughts on testing setup for this?
Because ApiConnection depends on userAgent function, and it depends on ZulipBinding to get package & device info — now every test that uses ApiConnection will need to also first call TestZulipBinding.ensureInitialized().
Alternative implementation is to make ApiConnection take userAgent in it's constructor, which would just move this reposnibility to the upper layer (LiveGlobalStore for the real impl), but that would remove the need for TestZulipBinding.ensureInitialized() in each test.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm yeah, having to do that in all the tests would be annoying.
It looks like there's only a small number of call sites of the ApiConnection constructors. So that's probably a good solution.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm after trying the alternative I suggested, ended up reverting it, because it led to a bit of constructor argument poisoning up the chain of ApiConnection — making it harder to read the code where some constructors calls take the userAgent value, and some leave it out (null).
Instead ended up refactoring first implementation by moving the userAgent function to class ZulipBinding to make it explicit that it now depends on members in ZulipBinding and thus the binding needs to be initialized to get user-agent.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What were the callers that that propagated up the call chain to? I was imagining it'd be just that ApiConnection.live requires a real user-agent, and FakeApiConnection defaults to a boring fake one, and then very little other code has to get involved.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The implementation I tried was to keep deviceInfo and packageInfo as async function instead of the current sync getters on ZulipBinding and then trying to populate the ApiConnection args (deviceInfo & packageInfo) from the async functions it's being called. But there was one call which was not async, so had to add the args for it too. And doing so led to many other changes in the codebase which were moot, thus reverted it.
But if we stick to sync function userAgentHeader and let ApiConnection.live inline it in it's constructor like:
ApiConnection.live({
...
}) : this(client: http.Client(),
...
userAgentHeader: ZulipBinding.instance.userAgentHeader());then it does work without the need to poison the callers, I'll send a revision with this now.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Okay just tried it, and seems like there isn't much difference in line count, because now some tests that needs to check userAgent needs to pass the testBinding.userAgent to FakeApiConnection.with_ which are quite a few.
But on the plus side, the tests that don't check userAgent don't need to add ZulipBinding initialization lines:
- TestZulipBinding.ensureInitialized();
- tearDown(testBinding.reset);There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Okay just tried it, and seems like there isn't much difference in line count, because now some tests that needs to check userAgent needs to pass the
testBinding.userAgenttoFakeApiConnection.with_which are quite a few.
Cool. Yeah, even if the total line count is the same but the impact is all concentrated in the one group of tests that's about the user-agent value itself, that sounds a lot nicer.
We can probably also improve that further with a local helper or two in those tests.
2a32df4 to
8458b72
Compare
lib/main.dart
Outdated
| LicenseRegistry.addLicense(additionalLicenses); | ||
| LiveZulipBinding.ensureInitialized(); | ||
| WidgetsFlutterBinding.ensureInitialized(); | ||
| await LiveZulipBinding.ensureInitialized(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The goal for this change is to pre-fetch the deviceInfo and packageInfo while ZulipBinding is being initialized and then later they can be used from non-async functions via the getters.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That make sense from an ergonomics perspective; but it makes me nervous to be introducing this wait into the startup path. Probably on any reasonable system the queries will return quickly and it's fine, but
- there may be bugs in these third-party plugins that cause them to sometimes hang before supplying an answer;
- there may be devices out there where some bug in a vendor's low-quality modifications to the Android platform software cause the queries to hang, or just to be slow, even though the plugins are doing only perfectly reasonable things that totally ought to be reliably fast.
Then the thing about putting awaits like these into the startup path is that it'd escalate any bugs of those types into a serious problem in the Zulip app for affected users.
So let's arrange things instead so that if for some reason those queries are slow, or even hang forever and never return, everything fails gracefully — just the places that actually wanted to use the answers have to make do with not knowing the answers.
One good way to do that, API-wise, would be to indeed make those into nice non-async getters, but returning nullable. Then when the binding is initialized, it kicks off the queries, and if the queries have finished by the time we try to make a request then we use the results; if not, we fall back to not knowing this information.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess most of the time we'll be firing off one request promptly after startup, the register-events request. So if we find that that often happens before the platform queries finish, then we can add an extra wrinkle where we wait for the queries to finish… but with a timeout, like 10ms or something, so we prioritize the user's experience over being sure we include this information.
Let's keep that as a separate followup commit, though. Or it'd be great if you try a little manual experimenting to see if that's a problem in practice; if it seems like it isn't, we can skip that wrinkle entirely, and keep it in our back pocket as a possible followup for later if it turns out to be a problem sometimes in wider deployment.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, yeah we definitely should have a fail safe if plugins fails for some reason or are slow to retrieve the data.
The latest revision now doesn't await on pre-fetchs for both (deviceInfo and packageInfo), and where the getters are called there's now some kind of relevant fallback just-in-case.
if you try a little manual experimenting to see if that's a problem in practice
I tried that, by throw-ing in case either of infos are null but didn't see any exceptions in both release and debug builds for Android & macOS (can't try release on iOS Simulator) during startup.
a68aaca to
a9cf8d3
Compare
sm-sayedi
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @rajveermalviya for this. LGTM! Just a few (small) comments below!
lib/widgets/clipboard.dart
Outdated
| // If there was a failure while fetching `deviceInfo`, and we don't | ||
| // know which os/device variant we are on, display the snackbar anyway. | ||
| null => true, | ||
| _ => true, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| // If there was a failure while fetching `deviceInfo`, and we don't | |
| // know which os/device variant we are on, display the snackbar anyway. | |
| null => true, | |
| _ => true, | |
| // When there is a failure while fetching [deviceInfo] (`ZulipBinding.instance.deviceInfo == null`), | |
| // and we don't know which os/device variant we are on, display the snackbar anyway. | |
| _ => true, |
I think we can remove null => true.
lib/model/binding.dart
Outdated
| /// | ||
| /// Uses [deviceInfo] to get operating system information | ||
| /// and [packageInfo] to get application version information. | ||
| Map<String, String> userAgentHeader(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about adding this change in its separate commit?!
| } | ||
|
|
||
| /// Like [device_info_plus.MacOsDeviceInfo], but without things we don't use. | ||
| class MacOsDeviceInfo extends BaseDeviceInfo { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it will also be nice to add the classes for these new platforms and the related changes to LiveZulipBinding._prefetchDeviceInfo in a separate commit too!
lib/model/binding.dart
Outdated
| } | ||
|
|
||
| @visibleForTesting | ||
| Map<String, String> buildUserAgentHeader(BaseDeviceInfo deviceInfo, PackageInfo packageInfo) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
api: Embed platform and app info in user-agent
After adding the previous changes to their separate commits, now this commit can focus on embedding platform and app info in the user-agent as the commit message suggests.
6e08702 to
798aec6
Compare
|
Thanks for the review @sm-sayedi, pushed a new revision! |
sm-sayedi
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the revision. Just added two comments (very small) below, otherwise looks good to me.
Let's move on to the mentor review with @sumanthvrao.
lib/model/binding.dart
Outdated
| AndroidDeviceInfo({ | ||
| required this.release, | ||
| required this.sdkInt, | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| AndroidDeviceInfo({ | |
| required this.release, | |
| required this.sdkInt, | |
| }); | |
| AndroidDeviceInfo({required this.release, required this.sdkInt}); |
nit: Can be inlined.
lib/model/binding.dart
Outdated
| LinuxDeviceInfo({ | ||
| required this.name, | ||
| required this.versionId, | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| LinuxDeviceInfo({ | |
| required this.name, | |
| required this.versionId, | |
| }); | |
| LinuxDeviceInfo({required this.name, required this.versionId}); |
798aec6 to
7ba0e82
Compare
|
Thanks for the review @sm-sayedi, updated. |
chrisbobbe
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! Small comments below.
| LicenseRegistry.addLicense(additionalLicenses); | ||
| LiveZulipBinding.ensureInitialized(); | ||
| WidgetsFlutterBinding.ensureInitialized(); | ||
| LiveZulipBinding.ensureInitialized(); | ||
| NotificationService.instance.start(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do these lines need to be reordered?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes they need to be reordered, since deviceInfo is being prefetched in LiveZulipBinding.ensureInitialized() and PlatformChannels require WidgetsFlutterBinding to be initialized first.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What does the error look like if the order of these gets swapped back?
If it doesn't make clear that the problem is that WidgetsFlutterBinding is needed first, then ideally we can do something to make the error clear.
If that's hard or annoying, then just a comment here explaining this ordering constraint would also be an acceptable solution.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here's how the errors look like:
flutter: Failed to prefetch device info: Binding has not yet been initialized.
The "instance" getter on the ServicesBinding binding mixin is only available once that binding has been initialized.
Typically, this is done by calling "WidgetsFlutterBinding.ensureInitialized()" or "runApp()" (the latter calls the former). Typically this call is done in the "void main()" method. The "ensureInitialized" method is idempotent; calling it multiple times is not harmful. After calling that method, the "instance" getter will return the binding.
In a test, one can call "TestWidgetsFlutterBinding.ensureInitialized()" as the first line in the test's "main()" method to initialize the binding.
If ServicesBinding is a custom binding mixin, there must also be a custom binding class, like WidgetsFlutterBinding, but that mixes in the selected binding, and that is the class that must be constructed before using the "instance" getter.
#0 BindingBase.checkInstance.<anonymous closure> (package:flutter/src/foundation/binding.dart:309:9)
#1 BindingBase.checkInstance (package:flutter/src/foundation/binding.dart:390:6)
#2 ServicesBinding.instance (package:flutter/src/services/binding.dart:68:54)
#3 _findBinaryMessenger (package:flutter/src/services/platform_channel.dart:158:25)
#4 MethodChannel.binaryMessenger (package:flutter/src/services/platform_channel.dart:293:56)
#5 MethodChannel._invokeMethod (package:flutter/src/services/platform_channel.dart:327:15)
#6 MethodChannel.invokeMethod (package:flutter/src/services/platform_channel.dart:507:12)
#7 MethodChannelDeviceInfo.deviceInfo (package:device_info_plus_platform_interface/method_channel/method_channel_device_info.dart:19:24)
#8 DeviceInfoPlugin.macOsInfo (package:device_info_plus/device_info_plus.dart:86:48)
#9 DeviceInfoPlugin.deviceInfo (package:device_info_plus/device_info_plus.dart:107:16)
#10 LiveZulipBinding._prefetchDeviceInfo (package:zulip/model/binding.dart:338:62)
#11 new LiveZulipBinding (package:zulip/model/binding.dart:290:19)
#12 LiveZulipBinding.ensureInitialized (package:zulip/model/binding.dart:297:7)
#13 main (package:zulip/main.dart:16:20)
#14 _runMain.<anonymous closure> (dart:ui/hooks.dart:301:23)
#15 _delayEntrypointInvocation.<anonymous closure> (dart:isolate-patch/isolate_patch.dart:297:19)
#16 _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:184:12)
flutter: Failed to prefetch package info: Binding has not yet been initialized.
The "instance" getter on the ServicesBinding binding mixin is only available once that binding has been initialized.
Typically, this is done by calling "WidgetsFlutterBinding.ensureInitialized()" or "runApp()" (the latter calls the former). Typically this call is done in the "void main()" method. The "ensureInitialized" method is idempotent; calling it multiple times is not harmful. After calling that method, the "instance" getter will return the binding.
In a test, one can call "TestWidgetsFlutterBinding.ensureInitialized()" as the first line in the test's "main()" method to initialize the binding.
If ServicesBinding is a custom binding mixin, there must also be a custom binding class, like WidgetsFlutterBinding, but that mixes in the selected binding, and that is the class that must be constructed before using the "instance" getter.
#0 BindingBase.checkInstance.<anonymous closure> (package:flutter/src/foundation/binding.dart:309:9)
#1 BindingBase.checkInstance (package:flutter/src/foundation/binding.dart:390:6)
#2 ServicesBinding.instance (package:flutter/src/services/binding.dart:68:54)
#3 _findBinaryMessenger (package:flutter/src/services/platform_channel.dart:158:25)
#4 MethodChannel.binaryMessenger (package:flutter/src/services/platform_channel.dart:293:56)
#5 MethodChannel._invokeMethod (package:flutter/src/services/platform_channel.dart:327:15)
#6 MethodChannel.invokeMethod (package:flutter/src/services/platform_channel.dart:507:12)
#7 MethodChannel.invokeMapMethod (package:flutter/src/services/platform_channel.dart:534:49)
#8 MethodChannelPackageInfo.getAll (package:package_info_plus_platform_interface/method_channel_package_info.dart:13:32)
#9 PackageInfo.fromPlatform (package:package_info_plus/package_info_plus.dart:80:61)
#10 LiveZulipBinding._prefetchPackageInfo (package:zulip/model/binding.dart:367:56)
#11 new LiveZulipBinding (package:zulip/model/binding.dart:291:20)
#12 LiveZulipBinding.ensureInitialized (package:zulip/model/binding.dart:297:7)
#13 main (package:zulip/main.dart:16:20)
#14 _runMain.<anonymous closure> (dart:ui/hooks.dart:301:23)
#15 _delayEntrypointInvocation.<anonymous closure> (dart:isolate-patch/isolate_patch.dart:297:19)
#16 _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:184:12)
Basically both of the prefetch fail because of same reason — MethodChannel requiring WidgetsFlutterBinding initialized first.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool, that seems clear enough — thanks.
lib/widgets/clipboard.dart
Outdated
| // Otherwise always display the snackbar if: | ||
| // 1. It's any other os/device than Android. | ||
| // 2. deviceInfo == null, meaning there was a failure while fetching | ||
| // the deviceInfo, so we don't know which os/device variant the | ||
| // app is running on. | ||
| _ => true, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we explain the meaning of deviceInfo == null in a dartdoc on the deviceInfo getter? That seems more efficient than explaining it separately at each call site that handles the null case. Curious readers can look up the dartdoc to learn what it means.
Then I think 1., and the rest of this comment, can be removed too. I think the comment "Android 13+ shows its own popup…", above this, implies that other platforms don't show a popup.
lib/model/binding.dart
Outdated
| if (ZulipBinding._instance == null) { | ||
| LiveZulipBinding(); | ||
| final binding = LiveZulipBinding(); | ||
| binding._prefetchDeviceInfo(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
binding [nfc]: Refactor deviceInfo to a getter
Let's highlight that we're changing the logic so that the device info is prefetched. And since this seems like a behavior change (though a small one), let's not mark it NFC.
lib/widgets/about_zulip.dart
Outdated
| // At this point `ZulipBinding` should already be initialized | ||
| // so `packageInfo` will only be null if there was a failure | ||
| // while fetching it. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The _prefetchPackageInfo call isn't awaited in LiveZulipBinding.ensureInitialized, so that method completes before the info is fetched and stored. It may be true that "ZulipBinding should already be initialized", but that doesn't seem sufficient to ensure that we've waited long enough to get success or failure from the fetch.
For this screen, I think we're content with the existing behavior: show the info if we have it, and just show a placeholder if we don't.
lib/widgets/about_zulip.dart
Outdated
| ListTile( | ||
| title: Text(zulipLocalizations.aboutPageAppVersion), | ||
| subtitle: Text(_packageInfo?.version ?? '(…)')), | ||
| if (appVersion != null) | ||
| ListTile( | ||
| title: Text(zulipLocalizations.aboutPageAppVersion), | ||
| subtitle: Text(appVersion)), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a behavior change, so let's leave it out of a pure-refactor [nfc] commit. Before, when the app version is undetermined, we'd show a ListTile with subtitle "(…)". Now, in that case, the whole ListTile is omitted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
removed this behavior change
test/api/core_test.dart
Outdated
| return FakeApiConnection.with_(account: eg.selfAccount, (connection) async { | ||
| connection.prepare(json: {}); | ||
| await connection.get(kExampleRouteName, (json) => json, 'example/route', params); | ||
| check(connection.lastRequest!).isA<http.Request>() | ||
| ..method.equals('GET') | ||
| ..url.asString.equals('${eg.realmUrl.origin}$expectedRelativeUrl') | ||
| ..headers.deepEquals({ | ||
| ...authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey), | ||
| ...userAgentHeader(), | ||
| }) | ||
| ..body.equals(''); | ||
| return FakeApiConnection.with_(account: eg.selfAccount, | ||
| userAgentHeader: testBinding.userAgentHeader(), | ||
| (connection) async { | ||
| connection.prepare(json: {}); | ||
| await connection.get(kExampleRouteName, (json) => json, 'example/route', params); | ||
| check(connection.lastRequest!).isA<http.Request>() | ||
| ..method.equals('GET') | ||
| ..url.asString.equals('${eg.realmUrl.origin}$expectedRelativeUrl') | ||
| ..headers.deepEquals({ | ||
| ...authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey), | ||
| ...testBinding.userAgentHeader(), | ||
| }) | ||
| ..body.equals(''); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is a one-line change plus a reformatting with different whitespace; is that right? The whitespace change is kind of distracting to me as I review looking for meaningful changes. (Here and in several places below.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like the whitespace changes in question are all an indentation change. It's natural for those to happen together with substantive changes sometimes — for example they'll happen whenever adding or removing a wrapper widget around a child subtree.
A handy tool for reading such diffs is git log -b, which ignores indentation changes. With that, this diff hunk looks like:
- return FakeApiConnection.with_(account: eg.selfAccount, (connection) async {
+ return FakeApiConnection.with_(account: eg.selfAccount,
+ userAgentHeader: testBinding.userAgentHeader(),
+ (connection) async {
connection.prepare(json: {});
await connection.get(kExampleRouteName, (json) => json, 'example/route', params);
check(connection.lastRequest!).isA<http.Request>()
..method.equals('GET')
..url.asString.equals('${eg.realmUrl.origin}$expectedRelativeUrl')
..headers.deepEquals({
...authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey),
- ...userAgentHeader(),
+ ...testBinding.userAgentHeader(),
})
..body.equals('');There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(With git config diff.colorMovedWS, you can also get your default git log -p output to treat indentation changes the same way as moved code and give them a different background color. That's what I have in my gitconfig. I still use -b to get a clearer view in extreme cases.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool. Yeah; I used git log -b to help verify the change, but I think I could have looked closer at the regular git log -p output—it turns out I already have that git config option set the way you do, and here's what it looks like:
So, noticing that a lot of the text has the "moved" colors (magenta and a more bluish-green), and also the faint background color, would have been helpful. That background color does end up being pretty faint on my screen; I bet there's a way to adjust it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, exactly. And yeah, those colors are adjustable - they're the diff.oldMoved and friends found in my gitconfig. Definitely worth trying some quick changes to them to see what works best for you.
From the screenshot, it looks like your terminal's default background color is already a faint gray rather than black, which ends up being pretty close to the background colors set there. I have my terminal background set to pure black, for maximum contrast, and then the colors in my config are chosen to be distinct from that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The striping in your screenshot looks like it could get a bit annoying. You might be able to adjust the line spacing to fix that — generally I'd expect a terminal to use 1.0x line spacing anyway.
Yep, got it! I adjusted a font setting in the Android Studio settings. It was a little confusing because things outside the terminal were responding to the setting, but the terminal wasn't—but then it did after I restarted Android Studio.
| test('matches $userAgent', () async { | ||
| testBinding.deviceInfoResult = deviceInfo; | ||
| testBinding.packageInfoResult = pacakgeInfo; | ||
| await checkUserAgent(userAgent); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would be cleanest/most principled to add a line
addTearDown(testBinding.reset);at the top of the test, like we do in other tests. (But I don't think the lack of the line causes a problem with this test as written.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is a addTearDown(testBinding.reset) at the start of the main, which should work for all tests in that file.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh! Indeed; thanks for pointing this out.
test/api/core_test.dart
Outdated
| final testCases = [ | ||
| ('ZulipFlutter/0.0.1+1 (Android 14)', AndroidDeviceInfo(release: '14', sdkInt: 34), packageInfo), | ||
| ('ZulipFlutter/0.0.1+1 (iOS 17.4)', IosDeviceInfo(systemVersion: '17.4'), packageInfo), | ||
| ('ZulipFlutter/0.0.1+1 (macOS 14.5.0)', MacOsDeviceInfo(majorVersion: 14, minorVersion: 5, patchVersion: 0), packageInfo), | ||
| ('ZulipFlutter/0.0.1+1 (Windows)', WindowsDeviceInfo(), packageInfo), | ||
| ('ZulipFlutter/0.0.1+1 (Linux; Fedora Linux 40)', LinuxDeviceInfo(name: 'Fedora Linux', versionId: '40'), packageInfo), | ||
| ('ZulipFlutter/0.0.1+1 (Linux; Fedora Linux)', LinuxDeviceInfo(name: 'Fedora Linux', versionId: null), packageInfo), | ||
| ]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool! The way these cases is presented is nice and compact and easy to read, without lots of extra boilerplate for each case.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(Ah I did just notice there's a typo "pacakgeInfo" a few lines below this.)
7ba0e82 to
6ac9ab7
Compare
|
Thanks for the review @chrisbobbe, pushed a new revision PTAL. |
6ac9ab7 to
abbcfaf
Compare
chrisbobbe
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! Two small comments below, and I'll go ahead and mark this for Greg's review.
lib/model/binding.dart
Outdated
| /// Returns a Future resolving to null if an error was | ||
| /// encountered while fetching, otherwise a Future | ||
| /// resolving to the fetched device info. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| /// Returns a Future resolving to null if an error was | |
| /// encountered while fetching, otherwise a Future | |
| /// resolving to the fetched device info. | |
| /// The returned Future resolves to null if an error is encountered while | |
| /// fetching the data. |
This is a bit more concise, and I think the success case is clear from the return type (Future<BaseDeviceInfo?>). Callers might wonder what happens in the error case, though (does it reject, or resolve to null), so it's good to keep the mention of that.
Also, maybe good to put this after this line, instead of before it:
/// This wraps [device_info_plus.DeviceInfoPlugin.deviceInfo].since it's now just about what happens in the error case, which should be unusual.
(similarly with packageInfo below)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Addressed, though removed This wraps ... because the getter don't really wrap those functions anymore.
| @override | ||
| Future<BaseDeviceInfo> deviceInfo() async { | ||
| final deviceInfo = await device_info_plus.DeviceInfoPlugin().deviceInfo; | ||
| return switch (deviceInfo) { | ||
| device_info_plus.AndroidDeviceInfo(:var version) => AndroidDeviceInfo(sdkInt: version.sdkInt), | ||
| device_info_plus.IosDeviceInfo(:var systemVersion) => IosDeviceInfo(systemVersion: systemVersion), | ||
| _ => throw UnimplementedError(), | ||
| }; | ||
| } | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, and I didn't notice last time: is there a reason to move this device_info_plus-related code before other things in the class? I think ZulipBinding, LiveZulipBinding, and TestZulipBinding should declare their members in the same order; the consistency should help reading and editing them together.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same question 🙂
abbcfaf to
7023109
Compare
|
LGTM, thanks! Over to Greg, then. |
gnprice
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @rajveermalviya, and thanks @sm-sayedi and @chrisbobbe for the previous reviews!
Comments below. I've read fully through the first three commits:
7be37dd binding: Prefetch deviceInfo during initialization
f9ce9e5 binding: Add packageInfo getter
662346e about_zulip [nfc]: Refactor to use packageInfo from ZulipBinding
and I've skimmed through the remaining three commits:
6a0fa87 binding: Support more device/os variants for deviceInfo getter
4544709 binding [nfc]: Move userAgentHeader function to ZulipBinding
7023109 api: Embed platform and app info in user-agent
and left high-level comments that will lead to some refactors, so I'll read those in more detail in a later round of review.
| LicenseRegistry.addLicense(additionalLicenses); | ||
| LiveZulipBinding.ensureInitialized(); | ||
| WidgetsFlutterBinding.ensureInitialized(); | ||
| LiveZulipBinding.ensureInitialized(); | ||
| NotificationService.instance.start(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What does the error look like if the order of these gets swapped back?
If it doesn't make clear that the problem is that WidgetsFlutterBinding is needed first, then ideally we can do something to make the error clear.
If that's hard or annoying, then just a comment here explaining this ordering constraint would also be an acceptable solution.
lib/model/binding.dart
Outdated
| final binding = LiveZulipBinding(); | ||
| binding._deviceInfo = binding._prefetchDeviceInfo(); | ||
| binding._packageInfo = binding._prefetchPackageInfo(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: this logic is probably cleaner to read as a constructor body
| @override | ||
| Future<BaseDeviceInfo> deviceInfo() async { | ||
| final deviceInfo = await device_info_plus.DeviceInfoPlugin().deviceInfo; | ||
| return switch (deviceInfo) { | ||
| device_info_plus.AndroidDeviceInfo(:var version) => AndroidDeviceInfo(sdkInt: version.sdkInt), | ||
| device_info_plus.IosDeviceInfo(:var systemVersion) => IosDeviceInfo(systemVersion: systemVersion), | ||
| _ => throw UnimplementedError(), | ||
| }; | ||
| } | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same question 🙂
lib/model/binding.dart
Outdated
| BaseDeviceInfo? get maybeDeviceInfo => _maybeDeviceInfo; | ||
| BaseDeviceInfo? _maybeDeviceInfo; | ||
|
|
||
| @override | ||
| Future<PackageInfo?> get packageInfo => _packageInfo; | ||
| late Future<PackageInfo?> _packageInfo; | ||
|
|
||
| @override | ||
| PackageInfo? get maybePackageInfo => _maybePackageInfo; | ||
| PackageInfo? _maybePackageInfo; | ||
|
|
||
| Future<BaseDeviceInfo?> _prefetchDeviceInfo() async { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: keep "device info" members next to "device info" members, and "package info" members next to "package info" members
More generally, as the Flutter style guide puts it:
Fields should come before the methods that manipulate them, if they are specific to a particular group of methods.
where the key part of "before" is that they're next to those methods, though in particular they should come just before them.
lib/model/binding.dart
Outdated
| /// May return null if prefetching hasn't completed yet, | ||
| /// or an error occured while fetching the data. | ||
| BaseDeviceInfo? get maybeDeviceInfo; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| /// May return null if prefetching hasn't completed yet, | |
| /// or an error occured while fetching the data. | |
| BaseDeviceInfo? get maybeDeviceInfo; | |
| /// This is the value [deviceInfo] resolved to, | |
| /// or null if that hasn't resolved yet. | |
| BaseDeviceInfo? get maybeDeviceInfo; |
lib/model/binding.dart
Outdated
| /// Like [device_info_plus.WindowsDeviceInfo], currently only used to | ||
| /// determine if we're on Windows. | ||
| class WindowsDeviceInfo implements BaseDeviceInfo {} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I remember us having some discussion of why it's inconvenient to have more detail on Windows versions, but I've forgotten what the reason was. A short comment here (perhaps with a link to that previous thread) would be helpful for the next person who wonders why this one subclass has less information in it.
lib/model/binding.dart
Outdated
| /// Examples: 'Fedora', 'Debian GNU/Linux'. | ||
| /// | ||
| /// If not set, defaults to 'Linux'. | ||
| final String name; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| /// Examples: 'Fedora', 'Debian GNU/Linux'. | |
| /// | |
| /// If not set, defaults to 'Linux'. | |
| final String name; | |
| /// Examples: 'Fedora', 'Debian GNU/Linux', or just 'Linux'. | |
| final String name; |
As far as we're concerned, there's no question of setting it to something or not doing so — it just is. But the mention of the string "Linux" is useful in that given it's the default for the /etc/os-release file, it's a plausible value worth mentioning as an example.
lib/model/binding.dart
Outdated
| /// | ||
| /// This field is optional and may be null on some systems. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| /// | |
| /// This field is optional and may be null on some systems. |
The type already expresses that it might be null.
| /// Examples: '17', '11.04'. | ||
| /// | ||
| /// This field is optional and may be null on some systems. | ||
| final String? versionId; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's make explicit exactly what this corresponds to from the platform side:
| /// Examples: '17', '11.04'. | |
| /// | |
| /// This field is optional and may be null on some systems. | |
| final String? versionId; | |
| /// Examples: '17', '11.04'. | |
| /// | |
| /// Docs: https://www.freedesktop.org/software/systemd/man/latest/os-release.html#VERSION_ID= | |
| final String? versionId; |
test/api/fake_api.dart
Outdated
| Uri? realmUrl, | ||
| int? zulipFeatureLevel = eg.futureZulipFeatureLevel, | ||
| Account? account, | ||
| Map<String, String> Function()? userAgentHeader, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like this is always the same expression, when not null: testBinding.userAgentHeader.
How about making it a boolean instead? Like useBinding: true. That'd reduce the amount of noise at the call sites.
Then I think it can actually be a boolean up at the base class, too.
After that change, I think buildUserAgent can move to ApiConnection (e.g. as a private static), which is where it most logically belongs. We'd lose memoization between different ApiConnection instances, but that's fine — we aren't constantly making new connection instances.
7023109 to
66df466
Compare
|
Thanks for the reviews @chrisbobbe and @gnprice, pushed a new revision PTAL. |
gnprice
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the revision! Comments below.
With this round, I've now read through the whole PR.
| /// This is the value [packageInfo] resolved to, | ||
| /// or null if that hasn't resolved yet. | ||
| /// | ||
| /// This wraps [package_info_plus.PackageInfo.fromPlatform]. | ||
| PackageInfo? get syncPackageInfo; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
| /// This is the value [packageInfo] resolved to, | |
| /// or null if that hasn't resolved yet. | |
| /// | |
| /// This wraps [package_info_plus.PackageInfo.fromPlatform]. | |
| PackageInfo? get syncPackageInfo; | |
| /// This is the value [packageInfo] resolved to, | |
| /// or null if that hasn't resolved yet. | |
| PackageInfo? get syncPackageInfo; |
The way this is described in terms of packageInfo, and the copy of the "this wraps …" information on packageInfo, is enough to cover this fact.
test/model/binding.dart
Outdated
| /// The value that `ZulipBinding.instance.deviceInfo()` should return. | ||
| /// | ||
| /// See also [takeDeviceInfoCalls]. | ||
| @override | ||
| Future<BaseDeviceInfo?> get deviceInfo async => deviceInfoResult; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: keep the ordering these had, which matches the ordering of other members on this class:
- first the test-only data and methods for a given subsystem
- then the implementation (with
@override) of the app-facing members for that subsystem
| /// encountered while fetching the data. | ||
| /// | ||
| /// This wraps [package_info_plus.PackageInfo.fromPlatform]. | ||
| Future<PackageInfo?> get packageInfo; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit in commit message:
binding: Add packageInfo getter
This is a preparatory commit for the work of embedding the
device info in user-agent header.
copy-paste error 🙂
test/model/binding.dart
Outdated
| /// The value that `ZulipBinding.instance.deviceInfo` should return. | ||
| BaseDeviceInfo deviceInfoResult = _defaultDeviceInfoResult; | ||
| static final _defaultDeviceInfoResult = AndroidDeviceInfo(sdkInt: 33); | ||
| static final _defaultDeviceInfoResult = AndroidDeviceInfo(sdkInt: 33, release: '13'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
preexisting nit (would be a nice small added NFC commit):
| static final _defaultDeviceInfoResult = AndroidDeviceInfo(sdkInt: 33, release: '13'); | |
| static const _defaultDeviceInfoResult = AndroidDeviceInfo(sdkInt: 33, release: '13'); |
And make all the device-info and package-info constructors const so that that compiles.
lib/model/binding.dart
Outdated
| /// The major release number, such as 10 in version 10.9.3. | ||
| /// | ||
| /// See: https://developer.apple.com/documentation/foundation/operatingsystemversion/1414662-majorversion | ||
| final int majorVersion; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see, the description is a copy of the Apple description.
Even though this is a very small amount of text, out of an abundance of caution let's avoid copying it.
I think the name is already pretty nearly self-explanatory, so just the link is enough (as that allows eliminating any ambiguity about exactly what notion of "major release number" is meant):
| /// The major release number, such as 10 in version 10.9.3. | |
| /// | |
| /// See: https://developer.apple.com/documentation/foundation/operatingsystemversion/1414662-majorversion | |
| final int majorVersion; | |
| /// See: https://developer.apple.com/documentation/foundation/operatingsystemversion/1414662-majorversion | |
| final int majorVersion; |
(similarly minorVersion and patchVersion)
| class LinuxDeviceInfo implements BaseDeviceInfo { | ||
| /// A string identifying the operating system, without a version component, | ||
| /// and suitable for presentation to the user. | ||
| /// | ||
| /// Examples: 'Fedora', 'Debian GNU/Linux', or just 'Linux'. | ||
| /// | ||
| /// See: https://www.freedesktop.org/software/systemd/man/latest/os-release.html#NAME= | ||
| final String name; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh and I guess the two in this class are similar to the Android case (though I haven't checked what license the upstream doc is under).
For these let's do a reduced version similar to what I did for Android above: say what external thing it comes from; give example values; give the link. The examples in this revision are fine.
| testBinding.deviceInfoResult = deviceInfo; | ||
| testBinding.packageInfoResult = packageInfo; | ||
| await checkUserAgent(userAgent); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similar to my other comment about testBinding.reset and teardowns:
| testBinding.deviceInfoResult = deviceInfo; | |
| testBinding.packageInfoResult = packageInfo; | |
| await checkUserAgent(userAgent); | |
| testBinding.deviceInfoResult = deviceInfo; | |
| testBinding.packageInfoResult = packageInfo; | |
| addTearDown(testBinding.reset); | |
| await checkUserAgent(userAgent); |
I think that suffices for taking the place of the global tearDown call.
test/api/core_test.dart
Outdated
| final testCases = [ | ||
| ('ZulipFlutter/0.0.1+1 (Android 14)', AndroidDeviceInfo(release: '14', sdkInt: 34), packageInfo), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like packageInfo doesn't actually vary between these test cases. In that case, let's simplify by taking it out of the data list.
test/api/core_test.dart
Outdated
| .headers.deepEquals({ | ||
| ...authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey), | ||
| ...{'User-Agent': expectedUserAgent}, | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since this test is all about the User-Agent (and other tests are checking the auth headers), let's simplify:
| .headers.deepEquals({ | |
| ...authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey), | |
| ...{'User-Agent': expectedUserAgent}, | |
| }); | |
| .headers['User-Agent'].equals(expectedUserAgent); |
| useBinding: true, | ||
| (connection) async { | ||
| connection.prepare(json: {}); | ||
| await connection.get(kExampleRouteName, (json) => json, 'example/route', null); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's also pick any one of the test cases below, and do one check with each of the different request methods: get, post, postFileFromStream, delete.
66df466 to
7f2160c
Compare
|
Thanks for the review @gnprice, pushed a new revision PTAL. |
gnprice
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @rajveermalviya for the revision! This all looks great — merging.
| ..url.path.equals('/api/v1/messages') | ||
| ..bodyFields.deepEquals(expectedBodyFields) | ||
| ..headers['User-Agent'].equals(expectedUserAgent ?? userAgentHeader()['User-Agent']!); | ||
| ..headers['User-Agent'].equals(expectedUserAgent ?? kFallbackUserAgentHeader['User-Agent']!); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like how much smaller the impact of this on unrelated tests is now!
This is a preparatory commit for the work of embedding the device info in user-agent header.
This is a preparatory commit for the work of embedding the package info in user-agent header.
Generate the user-agent using `deviceInfo` and `packageInfo` from ZulipBinding. Fixes: zulip#467


Fixes #467